import { spawn as nodeSpawn } from "node:child_process";
import { existsSync, readFileSync } from "node:fs";
import { basename, join, relative, resolve } from "node:path";
import {
  formatAnnotationToHtml,
  getEnv,
  getSecret,
  isCI,
  isWindows,
  parseAnnotations,
  parseBoolean,
  printEnvironment,
  reportAnnotationToBuildKite,
  startGroup,
} from "./utils.mjs";

if (globalThis.Bun) {
  await import("./glob-sources.mjs");
}

// https://cmake.org/cmake/help/latest/manual/cmake.1.html#generate-a-project-buildsystem
const generateFlags = [
  ["-S", "string", "path to source directory"],
  ["-B", "string", "path to build directory"],
  ["-D", "string", "define a build option (e.g. -DCMAKE_BUILD_TYPE=Release)"],
  ["-G", "string", "build generator (e.g. -GNinja)"],
  ["-W", "string", "enable warnings (e.g. -Wno-dev)"],
  ["--fresh", "boolean", "force a fresh build"],
  ["--log-level", "string", "set the log level"],
  ["--debug-output", "boolean", "print debug output"],
  ["--toolchain", "string", "the toolchain to use"],
];

// https://cmake.org/cmake/help/latest/manual/cmake.1.html#generate-a-project-buildsystem
const buildFlags = [
  ["--config", "string", "build configuration (e.g. --config Release)"],
  ["--target", "string", "build target"],
  ["-t", "string", "same as --target"],
  ["--parallel", "number", "number of parallel jobs"],
  ["-j", "number", "same as --parallel"],
  ["--verbose", "boolean", "enable verbose output"],
  ["-v", "boolean", "same as --verbose"],
];

async function build(args) {
  const startTime = Date.now();

  if (process.platform === "win32" && !process.env["VSINSTALLDIR"]) {
    const shellPath = join(import.meta.dirname, "vs-shell.ps1");
    const scriptPath = import.meta.filename;
    return spawn("pwsh", ["-NoProfile", "-NoLogo", "-File", shellPath, process.argv0, scriptPath, ...args]);
  }

  if (isCI) {
    printEnvironment();
  }

  const env = {
    ...process.env,
    FORCE_COLOR: "1",
    CLICOLOR_FORCE: "1",
  };

  const generateOptions = parseOptions(args, generateFlags);
  const buildOptions = parseOptions(args, buildFlags);
  const ciCppBuild = isCI && !!process.env.BUN_CPP_ONLY;

  const buildPath = resolve(generateOptions["-B"] || buildOptions["--build"] || "build");
  generateOptions["-B"] = buildPath;
  buildOptions["--build"] = buildPath;

  if (!generateOptions["-S"]) {
    generateOptions["-S"] = process.cwd();
  }

  if (!generateOptions["-DCACHE_STRATEGY"]) {
    generateOptions["-DCACHE_STRATEGY"] = parseBoolean(getEnv("RELEASE", false) || "false") ? "none" : "auto";
  }

  const toolchain = generateOptions["--toolchain"];
  if (toolchain) {
    const toolchainPath = resolve(import.meta.dirname, "..", "cmake", "toolchains", `${toolchain}.cmake`);
    generateOptions["--toolchain"] = toolchainPath;
  }

  const generateArgs = Object.entries(generateOptions).flatMap(([flag, value]) =>
    flag.startsWith("-D") ? [`${flag}=${value}`] : [flag, value],
  );

  try {
    await Bun.file(buildPath + "/CMakeCache.txt").delete();
  } catch (e) {}
  await startGroup("CMake Configure", () => spawn("cmake", generateArgs, { env }));

  const envPath = resolve(buildPath, ".env");
  if (existsSync(envPath)) {
    const envFile = readFileSync(envPath, "utf8");
    for (const line of envFile.split(/\r\n|\n|\r/)) {
      const [key, value] = line.split("=");
      env[key] = value;
    }
  }

  const buildArgs = Object.entries(buildOptions)
    .sort(([a], [b]) => (a === "--build" ? -1 : a.localeCompare(b)))
    .flatMap(([flag, value]) => [flag, value]);

  await startGroup("CMake Build", () => spawn("cmake", buildArgs, { env }));

  if (ciCppBuild) {
    await startGroup("sccache stats", () => {
      spawn("sccache", ["--show-stats"], { env });
    });
  }

  printDuration("total", Date.now() - startTime);
}

function isBuildkite() {
  return process.env.BUILDKITE === "true";
}

function parseOptions(args, flags = []) {
  const options = {};

  for (let i = 0; i < args.length; i++) {
    const arg = args[i];

    for (const [flag, type] of flags) {
      if (arg === flag) {
        if (type === "boolean") {
          options[arg] = undefined;
        } else {
          options[arg] = args[++i];
        }
      } else if (arg.startsWith(flag)) {
        const delim = arg.indexOf("=");
        if (delim === -1) {
          options[flag] = arg.slice(flag.length);
        } else {
          options[arg.slice(0, delim)] = arg.slice(delim + 1);
        }
      }
    }
  }

  return options;
}

async function spawn(command, args, options, label) {
  const effectiveArgs = args.filter(Boolean);
  const description = [command, ...effectiveArgs].map(arg => (arg.includes(" ") ? JSON.stringify(arg) : arg)).join(" ");
  let env = options?.env;

  console.log("$", description);

  label ??= basename(command);

  const pipe = process.env.CI === "true";

  if (isBuildkite()) {
    if (process.env.BUN_LINK_ONLY && isWindows) {
      env ||= options?.env || { ...process.env };

      // Pass signing secrets directly to the build process
      // The PowerShell signing script will handle certificate decoding
      env.SM_CLIENT_CERT_PASSWORD = getSecret("SM_CLIENT_CERT_PASSWORD", {
        redact: true,
        required: true,
      });
      env.SM_CLIENT_CERT_FILE = getSecret("SM_CLIENT_CERT_FILE", {
        redact: true,
        required: true,
      });
      env.SM_API_KEY = getSecret("SM_API_KEY", {
        redact: true,
        required: true,
      });
      env.SM_KEYPAIR_ALIAS = getSecret("SM_KEYPAIR_ALIAS", {
        redact: true,
        required: true,
      });
      env.SM_HOST = getSecret("SM_HOST", {
        redact: true,
        required: true,
      });
    }
  }

  const subprocess = nodeSpawn(command, effectiveArgs, {
    stdio: pipe ? "pipe" : "inherit",
    ...options,
    env,
  });

  let killedManually = false;

  function onKill() {
    clearOnKill();
    if (!subprocess.killed) {
      killedManually = true;
      subprocess.kill?.();
    }
  }

  function clearOnKill() {
    process.off("beforeExit", onKill);
    process.off("SIGINT", onKill);
    process.off("SIGTERM", onKill);
  }

  // Kill the entire process tree so everything gets cleaned up. On Windows, job
  // control groups make this haappen automatically so we don't need to do this
  // on Windows.
  if (process.platform !== "win32") {
    process.once("beforeExit", onKill);
    process.once("SIGINT", onKill);
    process.once("SIGTERM", onKill);
  }

  let timestamp;
  subprocess.on("spawn", () => {
    timestamp = Date.now();
  });

  let stdoutBuffer = "";

  let done;
  if (pipe) {
    const stdout = new Promise(resolve => {
      subprocess.stdout.on("end", resolve);
      subprocess.stdout.on("data", data => {
        stdoutBuffer += data.toString();
        process.stdout.write(data);
      });
    });

    const stderr = new Promise(resolve => {
      subprocess.stderr.on("end", resolve);
      subprocess.stderr.on("data", data => {
        stdoutBuffer += data.toString();
        process.stderr.write(data);
      });
    });

    done = Promise.all([stdout, stderr]);
  }

  const { error, exitCode, signalCode } = await new Promise(resolve => {
    subprocess.on("error", error => {
      clearOnKill();
      resolve({ error });
    });
    subprocess.on("exit", (exitCode, signalCode) => {
      clearOnKill();
      resolve({ exitCode, signalCode });
    });
  });

  if (done) {
    await done;
  }

  printDuration(label, Date.now() - timestamp);

  if (exitCode === 0) {
    return;
  }

  if (isBuildkite()) {
    let annotated;
    try {
      const { annotations } = parseAnnotations(stdoutBuffer);
      for (const annotation of annotations) {
        const content = formatAnnotationToHtml(annotation);
        reportAnnotationToBuildKite({
          priority: 10,
          label: annotation.title || annotation.filename,
          content,
        });
        annotated = true;
      }
    } catch (error) {
      console.error(`Failed to parse annotations:`, error);
    }

    if (!annotated) {
      const content = formatAnnotationToHtml({
        filename: relative(process.cwd(), import.meta.filename),
        title: "build failed",
        content: stdoutBuffer,
        source: "build",
        level: "error",
      });
      reportAnnotationToBuildKite({
        priority: 10,
        label: "build failed",
        content,
      });
    }
  }

  if (signalCode) {
    if (!killedManually) {
      console.error(`Command killed: ${signalCode}`);
    }
  } else {
    console.error(`Command exited: code ${exitCode}`);
  }

  process.exit(exitCode ?? 1);
}

function printDuration(label, duration) {
  if (duration > 60000) {
    console.log(`${label} took ${(duration / 60000).toFixed(2)} minutes`);
  } else {
    console.log(`${label} took ${(duration / 1000).toFixed(2)} seconds`);
  }
}

build(process.argv.slice(2));
